面试知识库
高 困难

Agent Harness与Hook设计#

一句话答案#

Harness 是包在模型外面的控制层:终止判定、工具调用前后的校验与截断、上下文装配、权限、预算都归它管。做法是在 Agent 生命周期上开 hook 切点(会话开始、用户输入、模型调用前后、工具调用前后、准备结束、会话结束),把「不许怎么跑」写成按优先级执行的规则;prompt 只能劝模型,hook 能直接拦下。

核心要点

1. Harness 是什么:模型负责决策,Harness 负责约束#

同一个模型,换一套 Harness,表现可以差很多。模型只输出「下一步想做什么」(文本或 tool_call),放不放行、结果怎么处理、什么时候停,由外层代码决定:

职责Harness 做什么不做会怎样
终止判定判断「决策 → 行动 → 观察」循环何时真的该结束模型说一句「我这就去做」就停了,任务状态却是成功
工具调用前后校验参数、截断结果、包装错误超长结果把上下文撑爆;依赖挂了模型还在重试
上下文装配 system prompt、注入提示、裁剪旧结果注入位置不稳定,前缀缓存反复失效
权限读/写分级、按工具名放行、人工确认模型把「想买」当「确认买」,真的下了单
预算迭代数、墙钟时间、token/费用、检索次数死循环烧钱,没人知道
可观测每个 hook 的决策写进 trace被拦了都不知道是哪条规则拦的

和 [Agent Runtime与Checkpoint机制](/topics/ai-agent/Agent Runtime与Checkpoint机制) 的分工:Runtime 是执行引擎,驱动循环、调度工具,管任务生命周期(暂停/恢复/持久化);Harness 是挂在这个循环上的规则层,管单个回合内每一步能不能做、什么时候必须停。两者常在同一个框架里,面试时分开讲更清楚。

2. Hook 切点:在生命周期上开口子#

不同框架的事件名各不相同,但切点位置基本一致。先看通用模型:

flowchart LR
  A[会话开始 / 装配上下文] --> U[用户输入进入]
  U --> B[模型调用前]
  B --> C((模型推理))
  C --> D[模型调用后]
  D -->|有 tool_call| E[工具调用前]
  E -->|放行| F((执行工具))
  E -->|拒绝| B
  F --> G[工具调用后]
  G --> B
  D -->|无 tool_call| H[准备结束]
  H -->|规则要求继续| B
  H -->|检查通过| I[会话结束]
通用切点典型用途能做的动作
会话开始 / 装配期拼策略块、加载目录类信息、初始化日志改 system prompt、注入初始上下文
用户输入进入输入审核、越界拦截、补充上下文拦截输入、追加上下文
模型调用前看门狗、预算档位、裁剪旧工具结果注入提示、换模型、跳过本次调用
模型调用后漂移检测、检查 tool_call 是否合规写纠正提示、改写或丢弃本次输出
工具调用前权限、终结后拦截、顺序约束、熔断放行 / 拒绝(回一条给模型看的说明)/ 改参数 / 转人工确认
工具调用后(含失败)截断、外部内容加围栏、记终结、循环计数改写结果、追加提示
准备结束检查是否真的完成(模型想「空口收尾」)、最终答案审计阻止结束并要求再来一轮、改写或拒绝最终输出
会话结束脱敏、记账、清理资源只做副作用,不再影响本轮

此外还有一类旁路切点:上下文压缩前后、子 Agent 启动/结束、需要权限决策时。它们不在主循环上,但同样适合挂审计和治理逻辑。

主流框架的对应事件(事件清单随版本增加,以各自官方文档为准):

通用切点Claude Code hooks / Claude Agent SDKOpenAI Agents SDKLangChain v1 middleware
会话开始SessionStarton_agent_start(RunHooks)/ on_start(AgentHooks)before_agent
用户输入进入UserPromptSubmitinput guardrailbefore_agent
模型调用前无直接对应(用 UserPromptSubmit 注入上下文,压缩走 PreCompact)on_llm_start(只观察)before_model、wrap_model_call
模型调用后无直接对应on_llm_end(只观察)after_model、wrap_model_call
工具调用前PreToolUse(permissionDecision: allow / deny / ask,可用 updatedInput 改参数)、PermissionRequeston_tool_start(观察);tool input guardrail(拦截)wrap_tool_call
工具调用后PostToolUse、PostToolUseFailure、PostToolBatchon_tool_end(观察);tool output guardrail(拦截)wrap_tool_call
准备结束Stop、SubagentStop(decision: "block" 阻止结束)output guardrail(校验最终输出)after_model 里 jump_to="model" 让循环继续;after_agent
会话结束SessionEndon_agent_end / on_endafter_agent

几点区别值得记住:

  • Claude Code 的 hook 是外部命令(Agent SDK 里也可以是回调函数),用 matcher 按工具名过滤。退出码 2 表示阻止(能阻止的事件上生效),退出码 0 时可在 stdout 返回 JSON 做细粒度控制,被阻止的原因会回给模型。Claude Agent SDK 的 Python 和 TypeScript 版事件集不完全相同,部分事件(如 SessionStart、SessionEnd、PostToolBatch)只在 TypeScript 版提供。
  • OpenAI Agents SDK 把「观察」和「拦截」分开:RunHooks / AgentHooks 的 on_* 回调用来记录和埋点;要拦截就用 guardrail,返回 tripwire_triggered=True 后抛 InputGuardrailTripwireTriggered、OutputGuardrailTripwireTriggered 或 tool guardrail 对应的异常。input guardrail 默认和 Agent 并行跑(run_in_parallel=True),想在模型花 token 之前拦下,要设成 False。
  • LangChain v1 middleware 分两种:node 式(before_agent / before_model / after_model / after_agent,顺序执行,可用 jump_to 跳到 "model"、"tools"、"end")和 wrap 式(wrap_model_call / wrap_tool_call,包住一次调用,能重试、换模型、改请求或结果)。官方内置了 HumanInTheLoopMiddleware、SummarizationMiddleware、ModelCallLimitMiddleware、ToolCallLimitMiddleware、PIIMiddleware 等,本质就是现成的 harness 规则。

按「能做什么」可以把各家的 hook 分成四类:观察型(只记录)、拦截型(拒绝 / 抛 tripwire)、改写型(改参数、改结果、注入上下文)、跳转型(让循环继续或提前结束)。设计自己的 harness 时,每个切点都应该说清支持哪几类动作。

3. Hook 契约:几条决定可维护性的设计#

  • 钩子只决策,适配器落地:钩子改 ctx 或抛拒绝信号;换模型、拒工具、写状态统一在适配器里做,便于审计。
  • priority 是契约:比如「截断」必须早于「追加提示」,否则提示被截掉。顺序契约集中写在一个地方,不散在各文件。
  • 判据读事实,不维护影子状态:「本轮调过哪些工具」「候选数」是事实;再造一个阶段状态机去镜像它们,就多了一套要对账的真相。
  • 禁用工具不摘工具表:每轮工具列表保持不变,拒绝在执行层回哨兵文案。摘工具会让 prompt 前缀变化、缓存失效。
  • 异常策略要写明:fail-open(钩子挂了就跳过)保可用性,但安全钩子应只做纯计算,或内部 catch 后主动抛拒绝,走 fail-closed。

4. 规则分类:按「为什么拦」分五类#

类别判据例子动作软/硬
终止已调过终结工具;模型想结束但没调终结工具;迭代/墙钟超限拦后续工具;催一次;硬停并合成部分答案由软到硬
安全写工具未经确认;取消订单前本轮没查单;外部内容硬拒;加 <external_content> 围栏硬,不给逃生门
预算检索次数、token/费用、单工具配额降档模型 → 注入收尾指令 → 硬挡先软后硬
重复滑窗内同一工具出现过多;工具连续失败追加「换思路」提示;熔断一段时间多为软
漂移连续多轮没有实质进展;偏离用户原始约束注入纠偏提示软

一个关键区分:安全闸判精确事实,永远硬拒;效率闸判推定(「你大概不需要再搜了」),可能判错,所以要留逃生门——同一闸连拒 N 次后放行,避免把正确操作锁死。

5. 终止控制:从软到硬叠几层#

from collections import deque

class LoopDetector:
    """滑窗内同一工具出现次数超阈值 -> 提示,不硬停"""
    def __init__(self, window=6, threshold=4):
        self.recent = deque(maxlen=window)
        self.threshold = threshold

    def observe(self, tool_name: str) -> str | None:
        self.recent.append(tool_name)
        if self.recent.count(tool_name) >= self.threshold:
            return f"[系统提示] 近 {len(self.recent)} 次调用中 {tool_name} 已出现 " \
                   f"{self.recent.count(tool_name)} 次,请换思路或直接收尾。"
        return None
python
  1. 终结拦截:定义一组终结工具(出最终答案、下单卡片、追问用户),调用后置位;之后同一回合的其他工具调用一律拦截。并行调用要按「批次」判,同一条模型消息里的兄弟终结调用应放行,否则并发调度顺序决定结果。
  2. 空口收尾检测:模型不调工具就想结束、且本轮没调过终结工具 → 注入提示再来一轮。每次模型调用最多催 1 次,靠迭代上限封顶。
  3. 看门狗:用「最近一次工具成功」作为进展信号,停滞超过阈值先注入收尾指令,再超时就用已有结果合成部分答案。
  4. 迭代上限 + 墙钟超时:最后一道。超时建议把截止时刻下传到各出站调用(取 min(自身超时, 剩余时间)),而不是只在最外层一刀切。
  5. 循环检测:同工具同参数、滑窗内同工具高频都算。检测后优先提示,硬停交给上面几层。

6. 写操作的多级拦截#

四层依次是:注册时标 is_read_only 分出写工具 → 权限引擎对非只读工具默认挂起、按工具名逐个 ALLOW → 写工具只生成待确认卡,真正执行只走用户点按钮的 HTTP 接口 → 确认卡按 run_id + 动作 + 载荷指纹 做唯一约束。确认卡的字段、暂停方式和各种决议结局见 Human-in-the-loop确认交互设计。 配一条测试守住:断言「每个非只读工具都在放行表里」,否则某个写工具走了别的入口(比如 REST),会一直没人发现它在 Agent 里会被挂起。

7. Hook 规则冲突导致死循环:怎么查#

常见的三种形态:

形态现象修法
互相矛盾预算钩子注入「立即收尾」,资格闸却拦下收尾工具(例如「本轮没规划过不许出清单」)置一个「强制收尾授权」事实位,收尾指令发出时同步放开对应的闸
Stop 类钩子反复阻止结束每次想停都被要求继续,直到迭代上限催促次数封顶;阻止结束时带上「已阻止过」标记,第二次放行(Claude Code 的 Stop hook 输入里有 stop_hook_active 字段,hook 应据此判断是否已经在阻止后的续跑中,避免无限循环)
效率闸误判正确工具被一直拒,模型每轮重试同一个调用效率闸声明逃生门,连拒 N 次后放行,计数不清零(闩锁)

排查步骤:

  1. 每个 hook 的决策都进 trace:(point, hook, decision, reason, think_step),出问题先按回合拉时间线。
  2. 看是否出现「拒绝 A → 提示做 A → 再拒绝 A」的交替模式,定位是哪两条规则在打架。
  3. 检查每一条「你必须做 X」的注入,确认所有闸门都存在允许 X 的路径。
  4. 检查计数器是边沿触发还是电平触发:「连续 N 轮无进展」应在进展首次出现时重置;写成「有候选就重置」,候选出现后每轮都重置,漂移检测就失效了。
  5. 用离线回放复现:同一份模型输出序列喂给 Harness,确认是规则问题还是模型问题。

面试回答(2分钟版)

我理解的 Harness 就是模型之外的控制层。模型只负责说下一步想干什么,工具调用放不放行、结果怎么截断、有没有权限、预算还剩多少、什么时候必须停,都由 Harness 决定;循环本身由框架驱动。实现上是在 Agent 生命周期上开 hook 切点:会话开始、用户输入、模型调用前后、工具调用前后、准备结束、会话结束,每个切点挂一串按 priority 排序的钩子。主流框架都是这个结构,比如 Claude Code 的 PreToolUse、PostToolUse、Stop,OpenAI Agents SDK 的 guardrail 和生命周期回调,LangChain 的 before_model、wrap_tool_call 中间件。钩子只做决策,比如注入提示或者抛拒绝信号,真正落地由适配器统一执行。规则我按五类分:终止、安全、预算、重复、漂移。终止是从软到硬叠的:调了终结工具就拦后续调用,模型空口收尾就催一次,看门狗发现长时间没进展先催再硬停,最后是迭代上限和超时。写操作走多级拦截:只读标记、权限引擎按工具名放行、写工具只出确认卡,真正执行只走用户点按钮的接口。几个坑:安全闸判事实要硬拒,效率闸判推定要留逃生门,不然会把正确操作锁死;禁用工具别从工具表里摘,会破坏前缀缓存;规则之间可能互相打架导致死循环,所以每个钩子的决策都要进 trace。结合项目时可以讲:哪些规则从 prompt 挪到了 hook、闸分几类、用什么数据验证拦截没有误伤。

追问与易错

追问方向:

  • “规则直接写进 system prompt 不行吗?” → prompt 只能提高模型遵守的概率,hook 在执行层拦截,模型怎么想都过不去。典型是取消订单:模型可能编一个订单号,所以要在工具调用前的切点判断「本轮有没有先查单」,没有就硬拒。
  • “被拒的工具调用应该返回什么给模型?” → 返回一条写给模型的说明:为什么被拒、下一步该做什么(比如「已达检索上限,请用现有候选调用 summary 收尾」)。只回「permission denied」,模型大概率换个参数再撞一次。
  • “为什么禁用工具不从工具列表里删?” → 工具定义在 prompt 前部,每轮变动会让前缀缓存从那里断开;在执行层拒绝并回哨兵文案,工具表保持字节级一致。代价是模型多一次「调了才知道被拒」的往返。
  • “并行工具调用时,终结拦截怎么判?” → 不能只用一个布尔位:并发执行时谁先跑完谁置位,兄弟调用就被拦。要记置位时的批次号(同一条模型消息共享),同批的终结调用放行,下一批一律拦。
  • “效率闸的逃生门会不会被滥用?” → 只给判推定的效率闸(比如「大概不需要再搜」),连拒 N 次放行;安全闸(计数超限、未确认写操作)不允许声明逃生门。同一批并行调用的多次拒绝只算一次。
  • “钩子本身抛异常怎么办?” → 默认 fail-open,治理代码的 bug 不拖垮主链路;安全钩子只做集合、正则这类纯计算,将来引入带 IO 或 LLM 的安全钩子,必须内部 catch 后主动抛拒绝(fail-closed)。
  • “外层有 asyncio.timeout,为什么还要把 deadline 下传?” → 外层超时是一刀切,出站调用不知道还剩多久;下传后各出站点取 min(自身超时, 剩余),剩 3 秒就不会再按 5 秒等。剩余见底时给一个很小的正数,因为有些客户端把 0 当无限等待。
  • “迭代上限设多少?” → 没有通用值,按正常任务的步数分布定,留出余量;超限要当错误上报并用中间结果收尾,而不是直接抛异常给用户。

易错点:

  • ❌ “Harness 就是框架的 Agent 类” → 框架提供循环,Harness 是你在循环外加的约束层,框架换了这层规则还在。
  • ❌ “所有闸都硬拒最安全” → 效率类判断会误判,全硬拒会把正确操作锁死,模型每轮重试同一个调用。
  • ❌ “用状态机描述阶段更清晰” → 阶段只是已发生事实的影子,自己维护不变量就成了第二套真相,对不上时不报错。